fix(billing): correct Conversation API actor model + usage table#49
Merged
Conversation
The Conversation API endpoint was wired through `agent_usage` with
`source='api'` and `user_id=keyData.keyId`, but `agent_usage`'s CHECK
constraint only accepts `'studio'|'byoa'` and its `user_id` FK points
at `profiles(id)`. Both violations fire on the first DB call, so the
endpoint has been unreachable since it shipped. The system was never
production-deployed, so there is no data to migrate — this PR
restructures the model before the route can be opened.
Code review concluded that squeezing API usage into the per-user
`agent_usage` table with nullable user_id + COALESCE-UNIQUE was the
wrong shape: API key actors differ from user actors in lifecycle,
scope, permissions, and cap semantics. The same key-keyed aggregate
shape will be needed for MCP Cloud keys, so a dedicated table is the
forward-compatible model.
Migration 006:
- New `api_message_usage (workspace, api_key, month)` aggregate with
message + token counters and a workspace-admin SELECT RLS policy.
- `increment_api_usage_if_allowed` RPC enforces BOTH the per-key
monthly cap and the workspace plan cap in one advisory-locked
transaction and reports which cap fired via a `reason`
discriminator so the 429 message can name the right limit.
- `increment_api_usage_tokens` RPC for post-AI token accounting.
- `conversations.user_id` relaxed to nullable, `api_key_id` added as
nullable FK → `conversation_api_keys`, and a
`num_nonnulls(user_id, api_key_id) = 1` CHECK enforces exactly one
actor per row. Existing rows all satisfy the check trivially.
Provider surface:
- `incrementAPIUsageIfAllowed` / `updateAPIUsageTokens` thin wrappers
over the new RPCs.
- `createApiConversation` is its own method (not a `createConversation`
overload) so the call site states intent. `getConversation` now
takes a discriminated union `{ userId } | { apiKeyId }` filter so
the type system rules out mixed ownership lookups.
- `getWorkspaceMonthlyAPIUsage` now reads from `api_message_usage`
instead of the always-empty `agent_usage.source='api'` slice.
EE wiring:
- `conversation-api.ts` calls the new RPC, computes both caps via
`getEffectiveLimit` (so overage-enabled workspaces fall through to
the meter outbox), uses the new conversation helpers, and persists
via the new `saveApiChatResult`.
- `conversation-keys.ts` update path now clamps
`monthly_message_limit` to the current `api.messages_per_month`
plan cap, matching the create path. Plan downgrades can no longer
leave keys with stale-high limits.
The Studio chat path (`agent_usage`, `saveChatResult`) is untouched.
`saveChatResult` is narrowed to `'byoa'|'studio'` since `'api'` is
now invalid by construction and routes through `saveApiChatResult`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Conversation API endpoint was wired through
agent_usagewithsource='api'anduserId=keyData.keyId, but:agent_usage_source_checkonly accepts'studio'|'byoa'agent_usage.user_idFK →profiles(id), API key id is not a profileBoth fire on the first DB call. System was never production-deployed (no data to migrate). Fix restructures the model before the route can be safely opened.
Why a separate table, not nullable user_id in agent_usage
Reviewed during this session. The "nullable user_id + COALESCE-UNIQUE" shape was rejected because:
(workspace, user, month, source)UNIQUE doesn't naturally extend with COALESCE — UNIQUE acrobatics for an alien actorA dedicated
api_message_usagetable:Changes
Migration
006_api_message_usage_and_conversation_actor.sqlapi_message_usage (workspace, api_key, month)aggregate, RLS for workspace admins, service-role writes only.increment_api_usage_if_allowedRPC — enforces per-key cap AND workspace plan cap in one advisory-locked tx; reports which cap fired viareasondiscriminator ('ok' | 'key_limit' | 'workspace_limit').increment_api_usage_tokensRPC for post-AI token accounting.conversations.user_idrelaxed to nullable;api_key_id uuid FK→conversation_api_keys ON DELETE CASCADEadded;CHECK (num_nonnulls(user_id, api_key_id) = 1)enforces exactly one actor.Provider surface
incrementAPIUsageIfAllowed/updateAPIUsageTokensRPC wrapperscreateApiConversation— explicit method, not an overload, so call sites state intentgetConversationfilter is now a discriminated union:{ userId: string } | { apiKeyId: string }getWorkspaceMonthlyAPIUsagerepointed to new table (was reading always-emptyagent_usage.source='api')EE wiring
conversation-api.tsuses the new RPC + helpers; computes both caps viagetEffectiveLimit(overage-enabled flows soft-cap correctly); 429 message names the right limit.conversation-keys.tsupdate path now clampsmonthly_message_limitto the workspace's currentapi.messages_per_monthcap. Plan downgrades can no longer leave stale-high key limits.Studio path
agent_usageschema and RPCs untouched.saveChatResultsource param narrowed to'byoa' | 'studio'—'api'is invalid by construction now and routes throughsaveApiChatResult.Out of scope (separate PRs, per agreed roadmap)
messages.content_blocks)Test plan
pnpm typecheckcleanpnpm lint— 0 errors on changed files (only pre-existing warnings)pnpm test:unit— 404 passed (was 403; newsaveApiChatResulttest added){ allowed: false, reason: 'key_limit' }and'workspace_limit'under their respective scenarios/api/conversation/v1/{projectId}/message— must return 200 (or a feature-gate 403), no 500